#!/usr/bin/env python

# Copyright (c) 2009, Purdue University
# All rights reserved.
# 
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
# 
# Redistributions of source code must retain the above copyright notice, this
# list of conditions and the following disclaimer.
#
# Redistributions in binary form must reproduce the above copyright notice, this
# list of conditions and the following disclaimer in the documentation and/or
# other materials provided with the distribution.
# 
# Neither the name of the Purdue University nor the names of its contributors
# may be used to endorse or promote products derived from this software without
# specific prior written permission.
# 
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

"""Tree sync tool for Roster"""


__copyright__ = 'Copyright (C) 2009, Purdue University'
__license__ = 'BSD'
__version__ = '#TRUNK#'


import sys
import shutil
import os
import ssh
import tarfile
import iscpy
import re
import time
import datetime
from fabric import api as fabric_api
from fabric import exceptions as fabric_exceptions
from fabric import state as fabric_state
from fabric import network as fabric_network
from roster_config_manager import config_lib

from optparse import OptionParser
import ConfigParser

from roster_core import constants

#BIND default
DEFAULT_RNDC_PORT = 953

class ConfigSyncer:
  def __init__(self, dns_server, config_lib_instance, rndc_exec='rndc', 
               ssh_id_file=None, dns_server_rndc_port=None, id=None):
    """
    Inputs:
      dns_server: name of the dns_server to push files to.
      config_lib_instance: ConfigLib object
      rndc_exec: string of path to rndc executable
      ssh_id_file: string of path to SSH private key file
      dns_server_rndc_port: int of port to connect rndc to
      id: int of audit log id of files we're sending over
    Raises:
      ExporterNoFileError: Could not find a DNS tree in the root_config_dir
      ServerCheckError:
        DNS server does not exist
        A server check has not been run on a DNS server
    """
    self.dns_server = dns_server
    self.config_lib_instance = config_lib_instance
    self.root_config_dir = config_lib_instance.root_config_dir
    self.rndc_exec = rndc_exec
    self.ssh_id_file = ssh_id_file
    self.dns_server_rndc_port = dns_server_rndc_port
    self.id = int(id)

    if( not os.path.exists(self.root_config_dir) or
        len(os.listdir(self.root_config_dir)) < 1 ):
      raise config_lib.ExporterNoFileError('Could not find a DNS tree in %s.' % 
                                           self.root_config_dir)
    
    self.dns_server_directory = '%s/%s' % (self.root_config_dir, self.dns_server)
    if( not os.path.exists(self.dns_server_directory) ):
      raise config_lib.ServerCheckError('DNS server %s does not exist. %s' % 
                                        dns_server)
    
    self.dns_server_info = config_lib_instance.GetDnsServerInfo(dns_server)
    self.bind_dir = self.dns_server_info['server_info']['bind_dir'].rstrip('/')
    self.test_dir = self.dns_server_info['server_info']['test_dir'].rstrip('/')
    self.ssh_host = '%s@%s:22' % (
        self.dns_server_info['server_info']['server_user'],
        self.dns_server_info['server_info']['server_name'])
    self.checkzone = ('named-checkzone' in self.dns_server_info['tools'] and 
                 self.dns_server_info['tools']['named-checkzone'])
    self.checkconf = ('named-checkconf' in self.dns_server_info['tools'] and 
                 self.dns_server_info['tools']['named-checkconf'])
    self.compilezone = ('named-compilezone' in self.dns_server_info['tools'] and
                   self.dns_server_info['tools']['named-compilezone'])
    self.tar = ('tar' in self.dns_server_info['tools'] and 
        self.dns_server_info['tools']['tar'])

    if( not self.dns_server_rndc_port ):
      rndc_port_found = False
      named_conf_string = open(os.path.join(
        self.root_config_dir, dns_server, 'named.conf.a'), 'r').read()
      named_conf_dict = iscpy.ParseISCString(named_conf_string)
      if( 'controls' in named_conf_dict ):
        named_conf_dict = named_conf_dict['controls']
        for sub_dict in named_conf_dict:
          for key in sub_dict:
            if( 'inet' in key and 'port' in key ):
              #Turns 'inet 192.168.what.ever port 1234 allow' into 
              #['inet 192.168.what.ever ', ' 1234 allow']
              port_split_list = key.split('port')

              if len(port_split_list) > 1:
                #Results in '1234 allow'
                port_segment = port_split_list[1].strip(' ')

                #Results in ' allow'
                to_strip = ''.join(re.split('\A[0-9]{1,5}', port_segment))

                #Results in '1234'
                port_string = port_segment.strip(to_strip) 

                if port_string.isdigit():
                  self.dns_server_rndc_port = int(port_string)
                  rndc_port_found = True

      if( not rndc_port_found ):
        print 'rndc port not found in named.conf. Using default of %s' % (
            DEFAULT_RNDC_PORT)
        self.dns_server_rndc_port = DEFAULT_RNDC_PORT

    if( self.dns_server_info['server_info']['bind_version'].lower() == 
        'undetermined' ):
      raise config_lib.ServerCheckError('A server check has not been run on %s.' %
                             self.dns_server)

  def run(self):
    """Pushes files to a remote directory over SSH, and refreshes rndc.
    Raises:
      ServerCheckError:
        Lost SSH connection to DNS server
        Unable to connect via SSH to DNS server
    """
    try:
      if( self.ssh_id_file is not None ):
        fabric_api.env.key_filename = self.ssh_id_file
      fabric_api.env.warn_only = True
      for out in fabric_state.output:
        fabric_state.output[out] = False
      fabric_api.env.host_string = self.ssh_host
      fabric_api.run('rm -rf %s' % os.path.join(self.test_dir, '*'))

      retry_methods = [self.MoveFilesAndUnTar, self.ReplaceBindDir, 
              self.ReloadBindServer]
      for method in retry_methods:
        tries = 0
        success = False
        while not success:
          try:
            tries += 1
            method()
            success = True
          except (fabric_exceptions.NetworkError, ssh.SSHException, 
              config_lib.ServerCheckError) as e:
            if tries == options.tries:
              raise e
    except fabric_exceptions.NetworkError:
      raise config_lib.ServerCheckError('Could not connect to %s via SSH.' % 
          self.dns_server)
    except ssh.SSHException:
      raise config_lib.ServerCheckError('Lost connection to %s.' % 
          self.dns_server)
    finally:
      fabric_network.disconnect_all()

  def ReloadBindServer(self):
    """Reloads BIND server via rndc reload
    Raises:
      ServerCheckError:
        Failed to reload DNS server via rndc reload
    """
    rndc_exec = '%s -p %s reload' % (
        self.rndc_exec, self.dns_server_rndc_port)

    result = fabric_api.run(rndc_exec)
    if( result.return_code != 0 ):
      raise config_lib.ServerCheckError('Failed to reload %s BIND '
          'server: %s.' % (self.dns_server, result))

  def CreateSymlink(self, symlink, target, write_redirect_named_conf=False):
    """Creates a symlink on the remote DNS server, with the option to
    write the "dummy" redirect named.conf

    Inputs:
      symlink: string - path of the symlink to create
      target: string - path of folder for symlink to point to
      write_redirect_named_conf: boolean - write the redirect named.conf

    Raises:
      ServerCheckError:
        Failed to create named.conf in the named directory
        Failed to create symlink
    """
    if( write_redirect_named_conf ):
      #symlink_named_conf is the "redirect" named.conf file. It simply includes
      #the contents of the named.conf that is "inside" the symlink directory.
      #After changing the symlink target, this file is now different, and thus
      #we have "updated" the "real" named.conf without touching the
      #named.conf that BIND looks at, which is the symlink_named_conf.
      symlink_named_conf = os.path.join(symlink, 'named.conf')

      #This comes out to be something like:
      #include "/etc/bind/named/named.conf";
      symlink_named_conf_string = 'include "%s";' % symlink_named_conf

      #This comes out to (staying in sync with the above example):
      #/etc/bind/named.conf
      bind_dir_named_conf = os.path.join(self.bind_dir, 'named.conf')

      echo_command = "echo '%s' > %s" % (symlink_named_conf_string, 
          bind_dir_named_conf)

      result = fabric_api.run(echo_command)
      if( result.return_code != 0 ):
        raise config_lib.ServerCheckError(
            'Failed to create named.conf in directory %s on server %s' % (
              self.bind_dir, self.dns_server))

    result = fabric_api.run('ln -s %s %s' % (target, symlink))
    if( result.return_code != 0 ):
      raise config_lib.ServerCheckError(
          'Failed to symlink %s to %s on server %s' % (target,
            symlink, self.dns_server))

  def ReplaceBindDir(self):
    """Moves the contents of test_dir into bind_dir
    Raises:
      ServerCheckError:
        BIND directory does not exist on remote DNS server
        Failed to move BIND files from test_dir to bind_dir
        Failed to remove bind_dir and create a symlink in its place
        Failed to change symlink target 
        Unknown symlink directory type
    """

    symlink_dir = os.path.join(self.bind_dir, 'named')
    new_bind_dir_real = '%s_%s_%s' % (symlink_dir.rstrip('/'), self.id, 
        datetime.datetime.now().strftime('%d_%m_%yT%H_%M_%S'))

    #Making sure self.bind_dir exists 
    result = fabric_api.run('file %s' % self.bind_dir)
    if( result.stdout == "%s: cannot open `%s' (No such file or directory)" % (
      self.bind_dir, self.bind_dir) ):
      raise config_lib.ServerCheckError(
          'BIND directory %s does not exist on server %s' % (self.bind_dir, 
            self.dns_server))

    #OK, it exists. Now let's move the test_dir there.
    test_dir_named_dir = os.path.join(self.test_dir, 'named')
    result = fabric_api.run('mv %s %s' % (test_dir_named_dir, new_bind_dir_real))
    if( result.return_code != 0 ):
      #check to see if it exists
      raise config_lib.ServerCheckError(
          'Failed moving %s to %s on server %s' % (test_dir_named_dir, 
            new_bind_dir_real, self.dns_server))

    result = fabric_api.run('file %s' % symlink_dir)
    if( result.stdout == "%s: cannot open `%s' (No such file or directory)" % (
      symlink_dir, symlink_dir) ):
      self.CreateSymlink(symlink_dir, new_bind_dir_real, 
          write_redirect_named_conf=True)

    #The BIND directory is an actual directory, not a symlink
    elif( result.stdout == '%s: directory' % symlink_dir ):
      result = fabric_api.run('rm -rf %s' % symlink_dir )
      if( result.return_code != 0 ):
        raise config_lib.ServerCheckError(
            'Failed to remove directory %s on server %s' % (symlink_dir, 
             self.dn_server))
      self.CreateSymlink(symlink_dir, new_bind_dir_real, 
          write_redirect_named_conf=True)

    #The BIND directory is a symlink
    elif( 'symbolic link to' in result.stdout ):
      #Turns "/some_dir/: symbolic link to `/some_other_dir'"
      real_bind_dir = result.stdout.split('symbolic link to')[1].lstrip(' `')[:-1]
      temp_symlink = '%s_link' % new_bind_dir_real.rstrip('/')

      self.CreateSymlink(temp_symlink, new_bind_dir_real)

      #This is key. This must (and does) run atomically.
      result = fabric_api.run('mv -T %s %s' % (temp_symlink, symlink_dir))
      if( result.return_code != 0 ):
        raise config_lib.ServerCheckError(
            'Failed to change %s symlink target from %s to %s on server %s' % (
              self.bind_dir, real_bind_dir, new_bind_dir_real, self.dns_server))

      result = fabric_api.run('rm -rf %s' % real_bind_dir)
      if( result.return_code != 0 ):
        print('WARNING: Failed to remove %s on server %s. This can be removed '
              'manually. However, it will not affect BIND operation.' % (
                  real_bind_dir, self.dns_server))
    else:
      raise config_lib.ServerCheckError('Unknown error in determing file type '
                                        'of %s on server %s' % (symlink_dir, 
                                            self.dns_server))

  def MoveFilesAndUnTar(self):
    """Moves files to remote server, untars, and runs checks
    Raises:
      ServerCheckError:
        Failed to move compressed/uncompressed BIND tree to DNS server
        Failed to untar compressed BIND tree on DNS server
        A named.conf check failed on the DNS server
        A zone did not pass named-checkzone
        Failed to compile zone
    """
    if( self.tar ):
      self.config_lib_instance.TarDnsBindFiles(self.dns_server)

      tar_file_name = '%s.tar.bz2' % self.dns_server
      local_tar_file_path = os.path.join(
              self.dns_server_directory, tar_file_name)
      remote_tar_file_path = os.path.join(self.test_dir, tar_file_name)

      result = fabric_api.run('ls %s' % self.test_dir) 
      if( result.return_code != 0 ):
        result = fabric_api.run('mkdir %s' % self.test_dir)
        if( result.return_code != 0 ):
          raise config_lib.ServerCheckError('Failed to move compressed BIND tree'
                                            ' to server %s' % self.dns_server)
            
      result = fabric_api.put(local_tar_file_path, remote_tar_file_path)
      if( not result.succeeded ):
        raise config_lib.ServerCheckError('Failed to move compressed BIND tree '
                                          'to server %s.' % self.dns_server)
      with fabric_api.cd(self.test_dir):
        result = fabric_api.run('tar -xf %s' % remote_tar_file_path)
      os.remove(os.path.join(
          self.root_config_dir, self.dns_server, tar_file_name))
      if( result.return_code != 0 ):
        raise config_lib.ServerCheckError('Failed to untar compressed BIND '
            'tree on server %s.' % self.dns_server)
      result = fabric_api.run('ls %s' % self.test_dir)
      if( 'named.conf.a' not in result ):
        raise config_lib.ServerCheckError('Failed to untar compressed BIND '
            'tree on server %s.' % self.dns_server)
      fabric_api.run('rm %s' % remote_tar_file_path)
    else:
      result = fabric_api.put('%s/*' % self.dns_server_directory, self.test_dir)
      if( not result.succeeded ):
        raise config_lib.ServerCheckError('Failed in moving BIND tree files '
            'to server %s.' % self.dns_server)

    dns_server_info_file = '%s.info' % self.dns_server
    fabric_api.run('rm %s' % os.path.join(self.test_dir, dns_server_info_file))

    remote_named_dir = os.path.join(self.bind_dir, 'named')
    result = fabric_api.run('ls %s' % remote_named_dir)
    if( result.return_code != 0 ):
      fabric_api.run('mkdir %s' % remote_named_dir)

    conf_keep = ''
    conf_remove = ''
    if( self.checkconf and self.compilezone ):
      result = fabric_api.run('named-checkconf %s/named.conf.b' % self.test_dir)
      if( result.return_code != 0 ):
        raise config_lib.ServerCheckError('Binary named.conf check failed '
            'on %s.' % self.dns_server)
    elif( self.checkconf ):
      result = fabric_api.run('named-checkconf %s/named.conf.a' % self.test_dir)
      if( result.return_code != 0 ):
        raise config_lib.ServerCheckError('Named.conf check failed on %s.' % 
            self.dns_server)
    
    if( self.compilezone ):
      conf_keep = os.path.join(self.test_dir, 'named.conf.b')
      conf_remove = os.path.join(self.test_dir, 'named.conf.a')
    else:
      conf_keep = os.path.join(self.test_dir, 'named.conf.a')
      conf_remove = os.path.join(self.test_dir, 'named.conf.b')
    fabric_api.run('rm %s' % conf_remove)
    #Moves the named.conf into the named folder
    fabric_api.run('mv %s %s' % (conf_keep, 
        os.path.join(self.test_dir, 'named', 'named.conf')))

    # Check and compile zone files
    if( self.checkzone or self.compilezone ):
      zone_dict = self.config_lib_instance.GetZoneList(self.dns_server)
      for view in zone_dict:
        view_dir = os.path.join(self.test_dir, 'named', view)
        for zone_origin in zone_dict[view]:
          zone_file_name = os.path.join(view_dir, zone_dict[view][zone_origin])
          zone_tool_arguments = self.config_lib_instance.GetNamedZoneToolArgs(
              self.dns_server, view, zone_dict[view][zone_origin])
          if( self.checkzone ):
            result = fabric_api.run('named-checkzone %s %s %s' % (
                zone_tool_arguments, zone_origin, zone_file_name))
            if( result.return_code != 0 ):
              raise config_lib.ServerCheckError('Zone %s did not pass the '
                                                'check.' % zone_origin)

          if( self.compilezone ):
            zone_binary_file_name = '%s.aa' % zone_file_name
            if( zone_file_name.endswith('.db') ):
              zone_binary_file_name = '%s.aa' % zone_file_name[:-3]
            result = fabric_api.run('named-compilezone -F raw %s -o %s %s %s' % (
                zone_tool_arguments, zone_binary_file_name, zone_origin,
                zone_file_name))
            fabric_api.run('rm %s' % zone_file_name)
            if( result.return_code != 0 ):
              raise config_lib.ServerCheckError('Failed to compile zone %s.' % 
                                     zone_origin)
            
            if( self.checkzone ):
              result = fabric_api.run('named-checkzone -f raw %s %s %s' % (
                  zone_tool_arguments, zone_origin, zone_binary_file_name))
              if( result.return_code != 0 ):
                raise config_lib.ServerCheckError('Zone check on binary zone '
                    '%s failed.' % zone_origin)

def main(args):
  """Collects command line arguments. Exports tree.

  Inputs:
    args: list of arguments from the command line
  """
  usage = ('\n'
           '\n'
           'To sync bind trees:\n'
           '\t%s -i <audit-id> [-c <config-file>] '
           '[-d <dest-directory>]\n' % sys.argv[0])

  parser = OptionParser(version='%%prog (Roster %s)' % __version__, usage=usage)
  
  parser.add_option('--export-config', action='store_true', dest='export_config',
                    help='This flag is used when dnsconfigsync is called from '
                    'dnsexportconfig. This should not be used by any user.',
                    metavar='<export_config>', default=False)
  parser.add_option('-d', '--dns-server', action='store', dest='dns_server',
                    help='DNS Server Name', metavar='<dns-server>',
                    default=None)
  parser.add_option('-c', '--config-file', action='store', dest='config_file',
                    help='Config File Location', metavar='<config-file>',
                    default=constants.SERVER_CONFIG_FILE_LOCATION)
  parser.add_option('-i', '--id', dest='id',
                    help='ID of tarfile output from Roster tree export.',
                    metavar='<id>', default=None)
  parser.add_option('--ssh-id', action='store', dest='ssh_id',
                    help='SSH id file.', metavar='<ssh-id>',
                    default=None)
  parser.add_option('--rndc-exec', action='store', dest='rndc_exec',
                    help='RNDC executable location.', metavar='<rndc-exec>',
                    default='rndc')
  parser.add_option('--rndc-key', action='store', dest='rndc_key',
                    help='RNDC key file.', metavar='<rndc-key>',
                    default=None)
  parser.add_option('--rndc-conf', action='store', dest='rndc_conf',
                    help='RNDC conf file.', metavar='<rndc-conf>',
                    default=None)
  parser.add_option('--rndc-port', action='store', dest='rndc_port',
                    help='RNDC communication port. If none provided, '
                    'named.conf will be parsed to find one. If one can not '
                    'be found, 953 will be used.', metavar='<rndc-port>',
                    default=None)
  parser.add_option('--ssh-failure-retries', action='store', dest='tries',
                    help='Number of times to retry config syncing should '
                    'an SSH error (e.g. timeout) occur. Defaults to 3.',
                    default=3)

  (globals()["options"], args) = parser.parse_args(args)

  if( options.dns_server is None ):
    print('ERROR: DNS server not given.')
    sys.exit(1)
  
  config_lib_instance = config_lib.ConfigLib(options.config_file)
  server_config_file = ConfigParser.SafeConfigParser()
  server_config_file.read(options.config_file)

  root_config_dir = os.path.abspath(os.path.expanduser(
      server_config_file.get('exporter', 'root_config_dir').rstrip('/')))

  if( not options.export_config ):
    if( os.path.exists(root_config_dir) ):
      shutil.rmtree(root_config_dir)
    options.id = config_lib_instance.UnTarDnsTree(options.id)

  if( options.rndc_key ):
    if( options.rndc_key.startswith('./')
        or not options.rndc_key.startswith('/') ):
      options.rndc_key = '%s/%s' % (os.getcwd(), options.rndc_key.lstrip('./'))
    options.rndc_exec = '%s -k %s' % (
        options.rndc_exec, os.path.expanduser(options.rndc_key))
  if( options.rndc_conf ):
    if( options.rndc_conf.startswith('./')
        or not options.rndc_conf.startswith('/') ):
      options.rndc_conf = '%s/%s' % (os.getcwd(),options.rndc_conf.lstrip('./'))
    options.rndc_exec = '%s -c %s' % (
        options.rndc_exec, os.path.expanduser(options.rndc_conf))

  try:
    ConfigSyncer(options.dns_server, config_lib_instance, 
        rndc_exec=options.rndc_exec, ssh_id_file=options.ssh_id,
        dns_server_rndc_port=options.rndc_port, id=options.id).run()
  except config_lib.ConfigManagerError as err:
    print('ERROR: %s' % err.message)
    sys.exit(1)

  if( not options.export_config ):
    shutil.rmtree(root_config_dir)

if __name__ == "__main__":
    main(sys.argv[1:])
